Skip to content

fix(sync): retry /save when the app does not answer, and carry feeds.opml - #380

Merged
ralyodio merged 1 commit into
mainfrom
settings-sync-retry-and-opml
Aug 13, 2026
Merged

fix(sync): retry /save when the app does not answer, and carry feeds.opml#380
ralyodio merged 1 commit into
mainfrom
settings-sync-retry-and-opml

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Two changes to settings sync, from chasing a /save that kept failing.

/save had no retry at all

request() was a single fetch: any non-2xx became a value the caller printed before exiting. So one transient blip was a visible failure — which is why it felt frequent. Every one of them was.

The failure is real and intermittent:

$ curl -X PUT .../api/settings   # real snapshot, valid token
502  {"status":"error","code":502,"message":"Application failed to respond","request_id":"7FC37h0hRYmYGfY_ljLL4A"}

Minutes later the identical payload returned 200, and a 20-probe sweep of the write path came back 20/20 healthy. The app is up; it just occasionally does not answer.

Retried only where a retry can be right

Status Retried? Why
0 (dead socket), 502, 503, 504 yes the platform returned it without the app seeing the request — the same bytes a second later are as likely to work
400 no it will be 400 again
401 no it wants /login, and says so
409 no another machine saved first; retrying only loses the same race again

Two retries at 400ms and 1200ms, then it is news.

Repeating a PUT is safe because the write is conditional. ifRevision pins the revision the caller last agreed on, so a retry landing after a first attempt secretly succeeded is refused with 409 rather than writing twice.

Worth noting the asymmetry this fixes: the ThreatCrush scan workflow this repository ships retries npm install three times, with a comment explaining that an unretried network call decides whether a security gate runs at all. The sync client had none.

feeds.opml joins SYNCED_FILES

A feed list is exactly the sort of thing that should follow a person to their next machine, and OPML is the format every reader already imports and exports. Written by tcfeed rss add (profullstack/threatcrush#118) and read by nothing here.

json: false on purpose — moshcode's interest in the file begins and ends with moving it. Unlike aliases.json, where a broken file is held back rather than copied to every machine you own, there is no such thing as a broken one here.

The server needs nothing. snapshotProblem() validates shape rather than filenames — no .., no leading slash, ≤32 files, ≤256KB — so it already accepts this. No deploy.

Tests

node --test test/settings-sync.test.mjs32 pass, 0 fail (27 before). Five new:

  • OPML travels byte for byte
  • a feed list that is not XML still syncs — nothing here reads it
  • a 502 is ridden out, and does not earn a line in the output
  • a 409 is asked exactly once
  • a 502 that never clears is still reported rather than retried forever

The existing "a network failure is a line and an exit code, never a throw" now takes 1.6s rather than 0 — that is the new backoff being exercised by a test that already existed.

🤖 Generated with Claude Code

…opml

Two changes to settings sync.

**Retry on 5xx.** `request()` was a single fetch: any non-2xx became a
value the caller printed before exiting, so one transient 502 was a
visible failure. app.moshcode.sh returns "Application failed to
respond" intermittently - a probe of the write path measured 20/20
healthy minutes after a real save had failed with exactly that - and
unretried, every blip is a failed save. It felt frequent because every
one of them was.

Retried only where a retry can be right. 502, 503, 504 and a dead
socket say nothing about the request: the platform returned them
without the app seeing it, so the same bytes a second later are as
likely to work as they were the first time. Every other status is an
answer - 400 will be 400 again, 401 wants /login, 409 is another
machine having saved first and retrying would only lose the same race
again.

Safe to repeat a PUT because the write is conditional: `ifRevision`
pins the revision the caller last agreed on, so a retry landing after a
first attempt secretly succeeded is refused with 409 rather than
writing twice.

Two retries at 400ms and 1200ms, then it is news. The scan workflow
this repository ships already retries `npm install` three times for
precisely this reasoning; the sync client had none.

**feeds.opml joins SYNCED_FILES.** A feed list is exactly the sort of
thing that should follow a person to their next machine, and OPML is
the format every reader already imports and exports. `json: false`:
moshcode's interest in the file begins and ends with moving it, so
unlike aliases.json there is no such thing as a broken one here.

The server needs nothing. It validates shape rather than filenames - no
`..`, no leading slash, 32 files, 256KB - so it already accepts this.

Five tests: OPML travels byte for byte, a non-XML feed list still syncs,
a 502 is ridden out without a line about it, a 409 is asked exactly
once, and a 502 that never clears is still reported rather than retried
forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

97 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 41 | LOW: 51

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:79
HIGH sh-remote-script-execution install.sh:83
HIGH tls-verification-disabled src/dns.mjs:741
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:61
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:75
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:101
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:265
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:269
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:314
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:499
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:675
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:677
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:736
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:782
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:852
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:955
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1063
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1199
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:1419
MEDIUM js-timing-unsafe-mac-compare apps/pwa/src/routes/settings-sync.mjs:179
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:192
MEDIUM sql-string-concatenation src/cli-schema.mjs:128
MEDIUM sql-string-concatenation src/cli-schema.mjs:417
MEDIUM sql-string-concatenation src/cli-schema.mjs:543
MEDIUM js-timing-unsafe-mac-compare src/settings-sync.mjs:315
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:310
MEDIUM insecure-temp-file test/news.test.mjs:479
MEDIUM insecure-temp-file test/plugins.test.mjs:152
MEDIUM insecure-temp-file test/pty.test.mjs:28
MEDIUM insecure-temp-file test/pty.test.mjs:31
MEDIUM insecure-temp-file test/pty.test.mjs:40
MEDIUM insecure-temp-file test/pty.test.mjs:42
MEDIUM insecure-temp-file test/pty.test.mjs:47
MEDIUM insecure-temp-file test/pty.test.mjs:48
MEDIUM insecure-temp-file test/pty.test.mjs:49
MEDIUM insecure-temp-file test/tabs.test.mjs:8
MEDIUM insecure-temp-file test/tabs.test.mjs:13
MEDIUM insecure-temp-file test/tabs.test.mjs:14
MEDIUM insecure-temp-file test/tabs.test.mjs:22
MEDIUM insecure-temp-file test/trust.test.mjs:240
LOW secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
LOW secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
LOW secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
LOW secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28

…and 47 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit b9ced4d into main Aug 13, 2026
4 checks passed
ralyodio added a commit that referenced this pull request Aug 13, 2026
…385)

`/save` failed with 502 every time unless you passed --force. The cause is one
missing clause, and it survived because it is invisible to the test suite.

insertRevision() builds two statements. The --force path sends ifRevision: null
and inserts unconditionally. The ordinary path carries a precondition and
expressed it as a HAVING on an implicit single-group aggregate:

  INSERT INTO settings_snapshots (…) SELECT …
  FROM settings_snapshots WHERE user_id = ?
  HAVING COALESCE(MAX(revision),0) = ?

SQLite treats the whole result as one group and runs that happily. Turso's
parser rejects it outright:

  SQL string could not be parsed: near HAVING, "None": syntax error

The route threw, the platform returned 502, and the CLI reported exactly what
it saw. #380 added retries for "the app does not answer", which could never
help: the statement is deterministically unparseable, so every attempt failed
the same way.

The tests could not catch it. They run against `file:` — a different engine
from the deployment — and test/settings-sync.mjs does cover the precondition,
with a stale ifRevision refused as 409 and a current one accepted. It passed
throughout, because on SQLite the statement is valid.

Adding GROUP BY user_id makes it parse on both. It cannot change the answer:
the caller sets ifRevision to null when the account has no current revision, so
there is always at least one row for this user to group.

Verified against the real database rather than only locally — the bare form
fails to parse on Turso, the grouped form parses and inserts nothing when the
precondition does not hold, which is the conflict the caller reports as 409.

test/sql-portability.test.mjs now fails on any HAVING in src/ with no GROUP BY.
Static rather than behavioural on purpose: the behaviour is correct on the
engine the tests use, so only reading the SQL can catch this. It matches the
uppercase keyword with comments stripped, because "having" is also an English
word and four files that contain no SQL said it in prose.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant